Default-probability curves

As usual, we’ll import the required modules and set the evaluation date. For convenience, we also define a constant that will let us write spreads more readably as 15 * bps instead of 0.0015.

import QuantLib as ql
import pandas as pd
today = ql.Date(27, ql.August, 2025)
ql.Settings.instance().evaluationDate = today
bps = 1e-4

Like for other types of curves, QuantLib provides a few classes to bootstrap default-probability term structures based on market data. Nowadays, the most common way to quote a credit default swap (CDS) is to assume a standard spread (100 bps or 500 bps, based on the market) and to quote an additional upfront payment as a fraction of the notional. The sign of the upfront is seen as paid by the protection buyer; in the data below, a negative quote means that the buyer would receive 0.2% of the notional when entering into a 1-year CDS and a positive quote means the buyer would pay 1.1% of the notional when entering a 10-years one.

cds_data = [
    (ql.Period(1, ql.Years), -0.002),
    (ql.Period(2, ql.Years), -0.005),
    (ql.Period(3, ql.Years), -0.008),
    (ql.Period(5, ql.Years), -0.004),
    (ql.Period(7, ql.Years), 0.007),
    (ql.Period(10, ql.Years), 0.011),
]

cds_spread = 100 * bps
recovery_rate = 0.4
cds_settlement_days = 1
upfront_settlement_days = 3

calendar = ql.UnitedStates(ql.UnitedStates.GovernmentBond)

The bootstrap process also needs an interest-rate curve as an input, in order to discount the CDS payments; a risk-free one, since the credit risk will be expressed by the default-probability curve itself. Here, for simplicity, I’m using a constant one.

risk_free_curve = ql.YieldTermStructureHandle(
    ql.FlatForward(0, calendar, 0.03, ql.Actual365Fixed())
)

As we’ve already seen for other kinds of bootstrapped curves, each of the market quotes is wrapped into a helper; in this case, the class to use is UpfrontCdsHelper. The helpers are then passed to the curve constructor. In C++, templates give us a choice of the interpolation method and of the underlying quantity to model; for instance, we might declare types such as PiecewiseDefaultCurve<SurvivalProbability,Linear>, PiecewiseDefaultCurve<HazardRate,BackwardFlat>, PiecewiseDefaultCurve<DefaultDensity,LogLinear> or any other combination. In Python, at the time of this writing, only PiecewiseFlatHazardRate is available.

helpers = [
    ql.UpfrontCdsHelper(
        quote,
        cds_spread,
        tenor,
        cds_settlement_days,
        calendar,
        ql.Quarterly,
        ql.ModifiedFollowing,
        ql.DateGeneration.CDS2015,
        ql.Actual360(),
        recovery_rate,
        risk_free_curve,
        upfront_settlement_days,
    )
    for tenor, quote in cds_data
]

probability_curve = ql.PiecewiseFlatHazardRate(
    0, calendar, helpers, ql.Actual365Fixed()
)

Once we have the curve, we can show its nodes. I’ll extract the corresponding probabilities of default, too.

pd.DataFrame(
    [
        (date, rate, probability_curve.defaultProbability(date))
        for date, rate in probability_curve.nodes()
    ],
    columns=["date", "hazard rate", "default probability"],
).style.format({"hazard rate": "{:.2%}", "default probability": "{:.2%}"})
  date hazard rate default probability
0 August 27th, 2025 1.27% 0.00%
1 June 22nd, 2026 1.27% 1.03%
2 June 21st, 2027 1.15% 2.16%
3 June 20th, 2028 1.13% 3.27%
4 June 20th, 2030 2.08% 7.20%
5 June 21st, 2032 2.89% 12.43%
6 June 20th, 2035 2.02% 17.57%

One thing to note: the modern convention for CDS is to have standardized maturity dates, rolling twice a year; you can see that by looking at the node dates, which don’t correspond to a whole number of years after the reference date. This means that, as the evaluation date rolls forward, the nodes of the curve stay fixed…

today = ql.Date(19, ql.September, 2025)
ql.Settings.instance().evaluationDate = today
pd.DataFrame(
    [
        (date, rate, probability_curve.defaultProbability(date))
        for date, rate in probability_curve.nodes()
    ],
    columns=["date", "hazard rate", "default probability"],
).style.format({"hazard rate": "{:.2%}", "default probability": "{:.2%}"})
  date hazard rate default probability
0 September 19th, 2025 1.23% 0.00%
1 June 22nd, 2026 1.23% 0.93%
2 June 21st, 2027 1.16% 2.06%
3 June 20th, 2028 1.14% 3.17%
4 June 20th, 2030 2.08% 7.11%
5 June 21st, 2032 2.89% 12.33%
6 June 20th, 2035 2.02% 17.48%

…until they suddenly jump ahead 6 months, when the CDS maturities roll.

today = ql.Date(22, ql.September, 2025)
ql.Settings.instance().evaluationDate = today
pd.DataFrame(
    [
        (date, rate, probability_curve.defaultProbability(date))
        for date, rate in probability_curve.nodes()
    ],
    columns=["date", "hazard rate", "default probability"],
).style.format({"hazard rate": "{:.2%}", "default probability": "{:.2%}"})
  date hazard rate default probability
0 September 22nd, 2025 1.41% 0.00%
1 December 21st, 2026 1.41% 1.74%
2 December 20th, 2027 1.14% 2.85%
3 December 20th, 2028 1.12% 3.94%
4 December 20th, 2030 2.09% 7.87%
5 December 20th, 2032 2.92% 13.09%
6 December 20th, 2035 2.03% 18.22%

Older conventions

Years ago, when CDS were less standardized, the convention was to quote the CDS spread instead of the upfront. If you need to deal with this kind of data, you can use the SpreadCdsHelper class:

old_cds_data = [
    (ql.Period(1, ql.Years), 70 * bps),
    (ql.Period(2, ql.Years), 80 * bps),
    (ql.Period(3, ql.Years), 100 * bps),
    (ql.Period(5, ql.Years), 140 * bps),
    (ql.Period(7, ql.Years), 170 * bps),
    (ql.Period(10, ql.Years), 210 * bps),
]

helpers = [
    ql.SpreadCdsHelper(
        quote,
        tenor,
        cds_settlement_days,
        calendar,
        ql.Quarterly,
        ql.ModifiedFollowing,
        ql.DateGeneration.CDS,
        ql.Actual360(),
        recovery_rate,
        risk_free_curve,
    )
    for tenor, quote in old_cds_data
]

old_probability_curve = ql.PiecewiseFlatHazardRate(
    0, calendar, helpers, ql.Actual360()
)
pd.DataFrame(
    [
        (date, rate, old_probability_curve.defaultProbability(date))
        for date, rate in old_probability_curve.nodes()
    ],
    columns=["date", "hazard rate", "default probability"],
).style.format({"hazard rate": "{:.2%}", "default probability": "{:.2%}"})
  date hazard rate default probability
0 September 22nd, 2025 1.16% 0.00%
1 December 21st, 2026 1.16% 1.46%
2 December 20th, 2027 1.55% 2.99%
3 December 20th, 2028 2.46% 5.39%
4 December 20th, 2030 3.57% 12.00%
5 December 20th, 2032 4.47% 19.64%
6 December 20th, 2035 5.84% 32.72%

Checking the correctness of the curve

In order to verify that the bootstrap process results in a consistent curve, we can choose one of the quotes (the 3-years one, for example), create the corresponding CDS, and check that it is fair; i.e., that its NPV is close to 0 within numerical accuracy.

test_tenor, test_upfront = cds_data[2]
test_tenor, test_upfront
(Period("3Y"), -0.008)

There are a few conventions to consider if we want to get the correct result. Usually, and in the parameters we passed to the helpers, the CDS settle at T+1 and the upfront is paid at T+3. As I mentioned, the maturity date is standardized and can be retrieved using the cdsMaturity function.

trade_date = today
start_date = calendar.advance(trade_date, cds_settlement_days, ql.Days)
upfront_date = calendar.advance(
    trade_date, upfront_settlement_days, ql.Days
)

maturity_date = ql.cdsMaturity(
    start_date, test_tenor, ql.DateGeneration.CDS2015
)
print(maturity_date)
December 20th, 2028

To get the correct schedule, we need to use this maturity date and to specify the CDS2015 convention for date generation:

schedule = ql.MakeSchedule(
    effectiveDate=today,
    terminationDate=maturity_date,
    frequency=ql.Quarterly,
    calendar=calendar,
    rule=ql.DateGeneration.CDS2015,
)

And finally, we can create the CDS itself—which, as of now, is a bit awkward to do. If we don’t pass the trade date explicitly, it seems to calculate it in a way that doesn’t match the one in the helper: something that we’ll need to investigate. And unfortunately, the trade date comes after a whole bunch of other parameters; C++ doesn’t provide a syntax to skip them, and even in Python the wrappers for the constructor don’t support keyword arguments (another thing that we’ll need to look into). This leads to the call below, which in my opinion has way too many parameters:

cds = ql.CreditDefaultSwap(
    ql.Protection.Buyer,
    10_000,
    test_upfront,
    100 * bps,
    schedule,
    ql.ModifiedFollowing,
    ql.Actual360(),
    True,
    True,
    start_date,
    upfront_date,
    ql.FaceValueClaim(),
    ql.Actual360(),
    True,
    trade_date,
)

As usual, in order to price the CDS, we need to provide an engine. For consistency with the default calculation in the helpers, we’ll build and set an engine based on the mid-point approximation, in which the default event (if any) is assumed to happen only at exactly half of the coupon life. The engine needs the default-probability curve we just bootstrapped, plus a risk-free curve for discounting and the recovery rate.

probability_handle = ql.RelinkableDefaultProbabilityTermStructureHandle(
    probability_curve
)
engine = ql.MidPointCdsEngine(
    probability_handle,
    recovery_rate,
    risk_free_curve,
)

cds.setPricingEngine(engine)

As expected, the quoted CDS is fair (within numerical accuracy):

cds.NPV()
3.699818229563334e-13

As a further check, we can also ask for the fair upfront, which returns the quoted value:

cds.fairUpfront()
-0.007999999999999964

Different models

As I mentioned, the helpers use the mid-point calculation unless told otherwise. However, it is also possible to choose the ISDA model by passing them an additional optional parameter:

helpers = [
    ql.UpfrontCdsHelper(
        quote,
        cds_spread,
        tenor,
        cds_settlement_days,
        calendar,
        ql.Quarterly,
        ql.ModifiedFollowing,
        ql.DateGeneration.CDS2015,
        ql.Actual360(),
        recovery_rate,
        risk_free_curve,
        upfront_settlement_days,
        model=ql.CreditDefaultSwap.ISDA,
    )
    for tenor, quote in cds_data
]

probability_curve = ql.PiecewiseFlatHazardRate(
    0, calendar, helpers, ql.Actual365Fixed()
)
pd.DataFrame(
    [
        (date, rate, probability_curve.defaultProbability(date))
        for date, rate in probability_curve.nodes()
    ],
    columns=["date", "hazard rate", "default probability"],
).style.format({"hazard rate": "{:.2%}", "default probability": "{:.2%}"})
  date hazard rate default probability
0 September 22nd, 2025 1.40% 0.00%
1 December 22nd, 2026 1.40% 1.74%
2 December 21st, 2027 1.14% 2.85%
3 December 21st, 2028 1.12% 3.94%
4 December 21st, 2030 2.09% 7.87%
5 December 21st, 2032 2.92% 13.10%
6 December 21st, 2035 2.03% 18.22%

The curve is now slightly different, and if we use it to price our sample CDS (which still uses the mid-point engine) its value is no longer zero:

probability_handle.linkTo(probability_curve)
cds.NPV()
-0.20866157093615761

However, using the IsdaCdsEngine provides a consistent calculation and, again, causes the CDS to be fair:

engine = ql.IsdaCdsEngine(
    probability_handle,
    recovery_rate,
    risk_free_curve,
)
cds.setPricingEngine(engine)
cds.NPV()
-2.8371749394295875e-13
cds.fairUpfront()
-0.00800000000000003

Curve interface

Usually, you’ll pass the curve to an engine and let it sort it out. However, if you want to inspect the curve or use it to write some different calculation, the curve provides a few different methods you can use. As we’ve already seen, it can return the default probability \(P\) up to a given date:

probability_curve.defaultProbability(ql.Date(12, ql.May, 2027))
0.021722225513824633

The survival probability is simply \(1 - P\), but there’s also a convenience method to obtain it directly:

probability_curve.survivalProbability(ql.Date(12, ql.May, 2027))
0.9782777744861754

And if you’re so inclined, you can also ask for the default density \(\rho = -\displaystyle{\frac{dS}{dt}}\)

probability_curve.defaultDensity(ql.Date(12, ql.May, 2027))
0.011182632606748252

…and the hazard rate \(h = \rho / S\).

probability_curve.hazardRate(ql.Date(12, ql.May, 2027))
0.011430938020258867

Different kinds of curves

As I mentioned, bootstrapped curves are currently based only on piecewise-flat hazard rates. If you have some pre-calculated data, though, you have a few more possibilities. For instance, you can use linear interpolation between survival probabilities at some given nodes:

survival_curve = ql.SurvivalProbabilityCurve(
    [
        today,
        today + ql.Period("1Y"),
        today + ql.Period("2Y"),
        today + ql.Period("5Y"),
        today + ql.Period("10Y"),
    ],
    [1.0, 0.995, 0.985, 0.97, 0.93],
    ql.Actual365Fixed(),
)
survival_curve.nodes()
((Date(22,9,2025), 1.0),
 (Date(22,9,2026), 0.995),
 (Date(22,9,2027), 0.985),
 (Date(22,9,2030), 0.97),
 (Date(22,9,2035), 0.93))

You can also use DefaultDensityCurve to interpolate linearly between default densities. In C++, you can also choose different interpolations. All these curves present the same interface and therefore can be used in the same way:

survival_curve.defaultProbability(ql.Date(12, ql.May, 2027))
0.011356164383561684
survival_curve.hazardRate(ql.Date(12, ql.May, 2027))
0.010114866081944281